You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This PR adds lazy, lazyPreloadDistance and renderLazyPlaceholder to PagerView
With lazy, a page mounts once the pager is within lazyPreloadDistance pages of it (default 1) and stays mounted after that. Pages are tracked by key, so adding or removing pages doesn't remount the others
Test Plan
Open "Lazy PagerView Example"
Change the preload distance, swipe and use the buttons.
The mounted counter and page map show what's mounted
e2e tests pass on iOS and Android
What's required for testing (prerequisites)?
What are the steps to reproduce (after prerequisites)?
Since the last review: 0 new · 4 still open · 13 no longer raised
3 finding(s): 1 major, 2 minor.
Required fixes
useImperativeHandle recreates the handle before nativeRef is set, leaving ref.current null forever
🟠 Major (1)
Where
Finding
src/PagerView.tsx:120
useImperativeHandle recreates the handle before nativeRef is set, leaving ref.current null forever
🟡 Minor (2)
Where
Finding
src/PagerView.tsx:128
setPage out of range sets lazy current, leaving the pager showing placeholders over an unmoved page
src/PagerView.tsx:149
Animated.event handlers are now wrapped in plain JS closures, forcing per-frame JS on every caller
4 findings below minor not shown (user_settings.min_severity).
Reviewed with this repository's own Sentinel configuration (d85bc5fd2fbe).
Reply under an inline comment with @apex-sentinel-staging note ... or @apex-sentinel-staging pivot ... to re-check that finding.
Comment @apex-sentinel-staging review on this conversation to run a full review.
Earlier reviews
Review of b0040e6d · 04.11.2026 11:43 UTC
Apex Sentinel
Verdict:approve
Since the last review: 0 new · 4 still open · 13 no longer raised
4 finding(s): 2 minor, 2 nit. Verdict: approve.
🟡 Minor (2)
Where
Finding
src/useLazyPages.tsx:12
Children without an element key collapse to the same pageKey "undefined" and share one mounted slot
src/useLazyPages.tsx:22
useLazyPages never updates current when the pager is moved without an onPageSelected event, leaving the visible page as a placeholder
🔵 Nit (2)
Where
Finding
src/PagerView.tsx:149
PagerView now always registers JS onPageScroll/onPageSelected handlers, forcing per-frame JS events for every caller (and breaking native-driver-only Reanimated usage)
src/useLazyPages.tsx:21
lazy is read only at first render for current (useState(initialPage)) and mountedKeys, so toggling lazy from …
Review of 1dab22bc · time not recorded
Apex Sentinel
Verdict:request_changes
Since the last review: 10 new · 0 still open · 7 no longer raised
Lazy mounting tracks pages only on the JS side: an unmounted page can become the native current page and then nothing mounts around it
🟠 Major (1)
Where
Finding
src/useLazyPages.tsx:58
🆕 Lazy mounting tracks pages only on the JS side: an unmounted page can become the native current page and then nothing mounts around it
🟡 Minor (2)
Where
Finding
src/useLazyPages.tsx:12
🆕 Stale pages leak into mountedKeys, and pageKey can alias two different pages, so a newly added page renders another page's already-mounted content
src/useLazyPages.tsx:29
🆕 mountedKeys only ever grows: keys of removed pages are retained for the pager's lifetime
🔵 Nit (7)
Where
Finding
src/PagerView.tsx:165
🆕 The new onPageSelected wrapper is installed unconditionally
src/useLazyPages.tsx:12
🆕 pageKey folds every keyless child into the single key "undefined"
src/useLazyPages.tsx:26
🆕 lazyPreloadDistance beyond the native offscreenPageLimit unmounts a page the native pager is currently showing
src/useLazyPages.tsx:27
🆕 lazyPreloadDistance is clamped for the effective window but the example's range map uses the raw prop, so the UI can disagree with what mounts
src/useLazyPages.tsx:40
🆕 State is set during render inside useLazyPages without a guard on the cause changing, so any re-render with a changed children/preloadDistance re-derives mountedKeys
src/useLazyPages.tsx:47
🆕 Placeholder <View> reuses the page's key, so React can reuse the instance when the page mounts
src/useLazyPages.tsx:53
🆕 renderLazyPlaceholder returns content for a page index, but that content is wrapped in a plain View whose only style is the page margin
The reason will be displayed to describe this comment to others. Learn more.
🟠 Major · useImperativeHandle recreates the handle before nativeRef is set, leaving ref.current null forever
On first mount, the useImperativeHandle factory runs before nativeRef.current is assigned (layout effects run before host refs are committed), returning null. Since the deps are [setCurrent] and setCurrent is stable, the factory never re-runs, so ref.current stays null for the pager's lifetime and every ref.current?.setPage(n) silently no-ops.
Suggested change
Make the imperative handle re-run once the native instance exists (e.g. store nativeRef in state via a ref callback and add that to the deps), and verify with a caller that calls setPage inside its own mount effect.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
A moderate finding in src/useLazyPages.tsx shows that mountedKeys is never pruned, so keys from removed pages can be reused and mount immediately even when far from the current page.
This PR adds a lazy mode to PagerView. With lazy on, a page's content mounts only when the pager gets within lazyPreloadDistance pages of it, and it stays mounted afterward. Pages are tracked by key, so adding or removing pages does not remount the others. The change also adds a README section, an example screen, and e2e tests.
Changes:
Adds lazy, lazyPreloadDistance (default 1), and renderLazyPlaceholder props to PagerView, backed by a new useLazyPages hook that tracks which pages are mounted.
Wires setPage and page selection into the hook so jumps mount the target page and its neighbours.
Adds a "Lazy PagerView Example" screen and e2e tests that check the mounted count and scroll position.
File
Description
src/useLazyPages.tsx
New hook that decides which pages are mounted, based on the current position and preload distance.
src/PagerView.tsx
Adds the lazy props and connects scroll, selection, and setPage to the hook.
README.md
Documents the lazy props and explains placeholders and offscreenPageLimit.
example/src/LazyPagerViewExample.tsx
New example screen that shows the mounted count, the page map, and preload controls.
example/src/App.tsx
Registers the new example in the example list.
e2e/tests/lazy-pager-view.e2e.ts
New e2e tests for mounting on jump and swipe, preload distance 0, and scroll position.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🟡 Minor · Stale pages leak into mountedKeys, and pageKey can alias two different pages, so a newly added page renders another page's already-mounted content
mountNear (useLazyPages.tsx:30-38) only ever unions into the set; nothing ever prunes keys that are no longer in pages. Combined with pageKey collapsing everything to a string (useLazyPages.tsx:12-13), pages.map (line 50) matches a different child element against a key left over from an earlier child. Concretely, with keyless children Children.toArray assigns positional keys .0, .1, …, so removing the first page of [A,B,C] re-keys the list to [.0=B, .1=C] and B renders immediately because .0 is still marked mounted from A — the two elements share one identity string while React sees two different elements. The README ("Pages are tracked by key, so adding or removing pages doesn't remount the others", README.md:131) states the guarantee without the precondition that keys be unique and stable, and pages whose keys are positional (the usePagerViewpages array, src/usePagerView.ts:49-62, which removePage shrinks from the end) is exactly the shape where the invariant silently holds or silently breaks depending on which end pages are removed from. The unbounded set itself is a small leak of string keys that never shrink as pages churn.
Suggested change
Note in the README Lazy pages section that children need unique, stable keys for the "adding or removing pages doesn't remount the others" guarantee, and prune mountedKeys against the current pages keys so removed pages do not pin their keys.
Details
persona: senior-developer
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🟡 Minor · mountedKeys only ever grows: keys of removed pages are retained for the pager's lifetime
mountNear builds next as a union of mounted and the nearby keys (useLazyPages.tsx:30-38) and nothing ever removes a key. onSelect/setCurrent move current, but a key whose page has left children (e.g. usePagerView's removePage, or any list that shrinks) stays in mountedKeys forever. The set is also ReadonlySet<string> state, so every mount event allocates a fresh Set copy. For a pager with a dynamically shrinking page list this is unbounded retention keyed on stale element keys, and it makes the documented promise "Pages are tracked by key, so adding or removing pages doesn't remount the others" (README.md:132) only half true: re-adding a page whose key is still in the set mounts it immediately with no placeholder, while a freshly added page gets one — the two directions are asymmetric for reasons a caller cannot see from the docs.
Suggested change
Prune keys that are no longer present in children when computing the next mounted set (or intersect with the current page keys), and note the mount-on-re-add behaviour in the Lazy pages docs section.
Details
persona: javascript
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🔵 Nit · pageKey folds every keyless child into the single key "undefined"
Children.toArray guarantees keys only for valid elements; a raw string/number child (which <PagerView>child</PagerView> accepts as children) yields pageKey === 'undefined', so all such pages share one entry in mountedKeys and mount/unmount together. Pages are documented as needing a key (README "Pages are tracked by key"), so this is reachable only by misuse — falling back to the index, e.g. ?? String(index), would make the degenerate case behave per-page instead of as one shared page.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🔵 Nit · lazyPreloadDistance beyond the native offscreenPageLimit unmounts a page the native pager is currently showing
useLazyPages.tsx:27 derives distance purely from preloadDistance, and PagerView passes the children prop straight through to the native component, so a page that leaves the mounted window is replaced in place by <View>{renderPlaceholder?.(index)}</View> (useLazyPages.tsx:49-55) — the child's content is swapped while the native pager may still be compositing it. Android's offscreenPageLimit defaults to 1 (src/PagerViewNativeComponent.ts:26 has no explicit default documented here, README.md:109 says "Defaults to 1", and the README's own Lazy section at 154 stresses it is "unrelated" to lazy). So <PagerView lazy lazyPreloadDistance={0} offscreenPageLimit={1}> — a configuration the README never warns against, and the lazy example does not use — unmounts the neighbour page that the pager keeps composed for the transition: the user dragging one page sees real content on one side and a placeholder on the other, then the content pops in on settle. Nothing in useLazyPages or PagerView ties distance to offscreenPageLimit or takes the max of the two.
Suggested change
Take the effective distance as max(lazyPreloadDistance, offscreenPageLimit ?? 1) in PagerView before calling useLazyPages, or document that lazyPreloadDistance must be >= offscreenPageLimit on Android.
Details
persona: senior-developer
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🔵 Nit · lazyPreloadDistance is clamped for the effective window but the example's range map uses the raw prop, so the UI can disagree with what mounts
useLazyPages computes the window as Math.max(preloadDistance, 0) + 1 (useLazyPages.tsx:27) and the hook itself takes no lower/upper validation of the prop: PagerViewProps.lazyPreloadDistance is a plain number with a default of 1 (PagerView.tsx:45,92). A caller passing a negative value gets distance = 1 (current page only, i.e. silently the same as 0) rather than an error, and a caller passing a fractional value such as 1.5 gets a window of 2 (an asymmetric, silently-rounded range). The public prop is documented in README.md:103 as "how many pages on each side … Defaults to 1", which neither of those behaviours matches. There is no validation or rounding at the prop boundary — the coercion is buried inside the +1 expression, so the documented semantics and the implemented window can diverge for any value that is not a non-negative integer.
Normalise lazyPreloadDistance once at the public boundary (Math.max(0, Math.round(n))) so the documented "pages on each side" matches the implemented window for negative and fractional inputs, and say so in the prop JSDoc.
Details
persona: javascript
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🔵 Nit · Placeholder <View> reuses the page's key, so React can reuse the instance when the page mounts
useLazyPages returns a fresh children array on every PagerView render, and for the default non-lazy path that array is props.children itself. That array identity is now what childrenWithOverriddenStyle (src/utils.tsx:8, src/utils.ios.tsx:8) iterates with Children.map, and the result feeds the native children prop of RNCViewPager. Two consequences:
Suggested change
return{
<Viewkey={`placeholder:${pageKey(page)}`}>
{renderPlaceholder?.(index)}
</View>
Suggested change
Give the placeholder a key distinct from the page's key (or a non-View wrapper type) so React cannot reuse the placeholder instance as the mounted page.
Details
Children.map skips nil/false/undefined children. A consumer using {cond && <Page/>} or pages.map(...).filter(...) style children already got the page rendered, but the array this hook hands over now comes from useLazyPages rather than being the raw props.children, so any conditional child is dropped from the pager rather than just rendering nothing — the same skipping existed before, so this half is not new; the new half is below.
More concretely: in lazy mode a page is replaced in place by <View key={pageKey(page)}>{renderPlaceholder?.(index)}</View> (src/useLazyPages.tsx:53). childrenWithOverriddenStyle clones that element with style: [element.props.style, StyleSheet.absoluteFill] and React.cloneElement on a <View> whose only child is the placeholder output. When the page mounts, React reconciles placeholder-View against the user's page element. For a page whose element type is View (the documented shape: "It is important all children are <View>s", src/PagerView.tsx:74), the type and key match, so React reuses the instance and the page's subtree mounts into the still-present placeholder children — which childrenWithOverriddenStyle also re-wraps in the absoluteFill wrapper. The result is that placeholder content and page content coexist in one reused wrapper for the transition, and anything renderLazyPlaceholder returns with its own state/animation (the README example returns <ActivityIndicator />) keeps running inside the mounted page instead of unmounting.
persona: senior-developer
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
The reason will be displayed to describe this comment to others. Learn more.
No longer raised on b0040e6.
🆕 🔵 Nit · renderLazyPlaceholder returns content for a page index, but that content is wrapped in a plain View whose only style is the page margin
For an unmounted page the hook returns <View key={...}>{renderPlaceholder?.(index)}</View>, and childrenWithOverriddenStyle then overwrites that View's style with { marginHorizontal: pageMargin } (src/utils.tsx:47) — no flex: 1. The README example (renderLazyPlaceholder={() => <View style={{flex: 1, justifyContent: 'center'}}><ActivityIndicator /></View>}) only fills the pager because the outer wrapper never stretches, so with pageMargin set the placeholder is additionally shifted sideways and vertically centered over a zero-flex container, which can read as an empty page. Documented behavior ("what a page shows before its content is mounted") therefore depends on the consumer adding a style the wrapper cannot carry. Either give the wrapper {flex: 1} and merge rather than overwrite in childrenWithOverriddenStyle, or say in the renderLazyPlaceholder jsdoc that the returned node is wrapped in a flex-less View.
Make the placeholder wrapper flex: 1 (or merge styles instead of overwriting them in childrenWithOverriddenStyle) so a placeholder that uses flex: 1 actually fills the page, and note the wrapper in the prop's doc comment.
Details
persona: maintainability
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
Apex Sentinel could not review commit 7a766d4a. No review ran, so there are no findings; this is not a verdict on the change.
Apex Sentinel could not read or check out this pull request, which is usually temporary (GitHub was slow, or the branch moved while the review waited). If it keeps failing, tell whoever runs Sentinel for this repository, or check that the App still has access to it. Push a new commit or comment @apex-sentinel-staging review to try again.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
A moderate finding in src/useLazyPages.tsx shows the selected page is only tracked while lazy is enabled, so turning it on after the user has moved can leave a stale index and a placeholder on the visible page until the user scrolls again.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Track selected page before lazy loading is enabled
src/useLazyPages.tsx:72
current only updates while lazy is on: setCurrent is undefined and onSelect ignores selections otherwise. If lazy is turned on after the user has moved, mountNear starts from a stale index. The visible page can then show its placeholder until the user drags again. Track the selected page unconditionally, and keep the lazy check only around the mounting in onScroll. This adds a state update, and so a re-render of PagerView, on every page change, including for pagers that don't use lazy. If that cost matters, store the page in a ref instead.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Minor · setPage out of range sets lazy current, leaving the pager showing placeholders over an unmoved page
setPage(100) on a 20-page pager sets lazy current to 100, causing every real page to render as a placeholder permanently because native never fires a corrective onPageSelected—the page never moved. The requested position is passed to setCurrent before validation, and mountNear in useLazyPages clamps only preloadDistance, never the page index against the child count. Clamp in useLazyPages (bounds known there from pages.length) or only adopt a position the native pager can actually select.
Details
Also raised here
Also raised at this location by persona senior-developer: setPage to an out-of-range page sets current and mounts pages native never shows — ref.current.setPage(999) on a 20-page lazy pager sets the hook's internal current to 999: mountNear then keeps every already-mounted key and mounts nothing new (no index is within distance of 999), so the pager keeps showing page 0 while every later setPage/swipe range is computed from a page that was never displayed — and in the new example, tapping Next/Prev after such a call drives the pager to 999/1000, which native clamps, so the buttons stop moving. The native pager never emits onPageSelected for an ignored page, so JS has no chance to correct current. setCurrent is called before the command with no bounds check against the child count that the hook already has in pages.length.
persona: javascript, senior-developer
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Minor · Animated.event handlers are now wrapped in plain JS closures, forcing per-frame JS on every caller
A caller passing onPageScroll={Animated.event(..., { useNativeDriver: true })} with no listener now pays per-frame JS dispatch, because PagerView always installs its own onPageScroll/onPageSelected props, wrapping the caller's handler in a plain function. The native-driver short-circuit only fires when the registered prop is an AnimatedEvent object directly, so the wrapper forces every scroll frame through JS for all existing callers.
Also raised at this location by persona senior-developer: onPageSelected/onPageScroll handlers are registered even when lazy is off — A caller that never uses lazy and passes only onPageScrollStateChanged now gets JS onPageScroll and onPageSelected props registered on the native view, because PagerView passes onPageScroll={onPageScroll}/onPageSelected={onPageSelected} unconditionally; RN then dispatches a JS event on every animation frame of every swipe and the wrapper forwards to props.onPageScroll?.() which is undefined. Previously these props reached the native component only when the user supplied them (base passed neither). It also changes the value of the onPageScroll prop the animated wrapper sees, which is the documented path for Reanimated UI-thread handlers (README:192-226, docs/docs/docs/guides/reanimated.md).
persona: javascript, senior-developer
Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.
Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds
lazy,lazyPreloadDistanceandrenderLazyPlaceholdertoPagerViewWith
lazy, a page mounts once the pager is withinlazyPreloadDistancepages of it (default 1) and stays mounted after that. Pages are tracked by key, so adding or removing pages doesn't remount the othersTest Plan
e2e tests pass on iOS and Android
What's required for testing (prerequisites)?
What are the steps to reproduce (after prerequisites)?
Compatibility
Checklist
README.md